Лабораторная работа 3 "Регистровый файл и внешняя память"

Процессор — это программно-управляемое устройство, выполняющее обработку информации и управление этим процессом. Очевидно, программа, которая управляет процессором, должна где-то храниться. Данные, с которыми процессор работает, тоже должны быть в доступном месте. Нужна память!

Цель

Описать на языке SystemVerilog элементы памяти для будущего процессора:

  • память команд (Instruction Memory);
  • память данных (Data Memory);
  • регистровый файл (Register File).

Допуск к лабораторной работе

Для успешного выполнения лабораторной работы, вам необходимо освоить:

Ход работы

  1. Изучить способы организации памяти (раздел #теория про память).
  2. Изучить конструкции SystemVerilog для реализации запоминающих элементов (раздел #инструменты).
  3. В проекте с прошлой лабораторной реализовать модули: Instruction Memory, Data Memory и Register File (#задание).
  4. Проверить с помощью тестового окружения корректность их работы.
  5. Проверить работу регистрового файла в ПЛИС.

Теория про память

Память — это устройство для упорядоченного хранения и выдачи информации. Различные запоминающие устройства отличаются способом и организацией хранения данных. Базовыми характеристиками памяти являются:

  • V — объем (количество бит данных, которые единовременно может хранить память);
  • a — разрядность адреса (ширина шины адреса, определяет адресное пространство — количество адресов отдельных ячеек памяти);
  • d — разрядность хранимых данных (разрядность ячейки памяти, как правило совпадает с разрядностью входных/выходных данных).

В общем случае V = 2^a * d.

Для объема памяти в 1 KiB (кибибайт, 1024 байта или 8192 бита) разрядность адреса может быть, например, 10 бит (что покрывает 2^10 = 1024 адреса), тогда разрядность хранимых данных должна быть 8 бит. 1024 * 8 = 8192, то есть 1 кибибайт. Если разрядность адреса, например, 8 бит (что покрывает 2^8 = 256 адресов), то разрядность данных d = V / 2^a это 8192 / 256 = 32 бита.

Однако, может быть такое, что не все ячейки памяти реально реализованы на кристалле микросхемы, то есть некоторые адреса существуют, но по ним не имеет смысла обращаться, а объем памяти, соответственно, не равен V ≠ 2^a * d — он меньше. Подобные случаи будут рассмотрены отдельно.

Память можно разделить на категории: ПЗУ (постоянное запоминающее устройство) и ОЗУ (оперативное запоминающее устройство). Из ПЗУ можно только считывать информацию, которая попадает в ПЗУ до начала использования памяти и не может изменяться в процессе работы. Из ОЗУ можно считывать и записывать информацию. В самом простом случае ПЗУ имеет один вход адреса addr и один выход считываемых данных read_data. На вход addr подается адрес требуемой ячейки памяти, на выходе read_data появляются данные, которые хранятся по этому адресу.

Для ОЗУ требуется больше сигналов. Кроме входного addr и выходного read_data добавляются: входные данные для записи write_data, сигнал синхронизации clk, который определяет момент записи данных и сигнал разрешения на запись write_enable, который контролирует нужно ли записывать данные или только считывать. Для того, чтобы записать информацию в такую память необходимо:

  • выставить адрес addr в который планируется запись данных,
  • выставить сами данные для записи на вход write_data,
  • установить сигнал write_enable в состояние разрешения записи (как правило это 1) и
  • дождаться нужного фронта clk — в этот момент данные будут записаны по указанному адресу. При этом, на выходе read_data будут старые данные, хранящиеся по адресу addr. На одном такте происходит одновременное считывание информации и запись новой.

Так же возможна реализация, в которой вход write_data и выход read_data объединены в единый вход/выход data. В этом случае операции чтения и записи разделены во времени и используют для этого один единый порт ввода-вывода (inout, двунаправленный порт) data.

../../.pic/Labs/lab_03_memory/fig_01.drawio.svg

Рисунок 1. Примеры блоков ПЗУ и ОЗУ.

Кроме того, различают память с синхронным и асинхронным чтением. В первом случае, перед выходным сигналом шины данных ставится дополнительный регистр, в который по тактовому синхроимпульсу записываются запрашиваемые данные. Такой способ может очень сильно сократить критический путь цифровой схемы, но требует дополнительный такт на доступ в память. В свою очередь, асинхронное чтение позволяет получить данные, не дожидаясь очередного синхроимпульса, но такой способ увеличивает критический путь.

Еще одной характеристикой памяти является количество доступных портов. Количество портов определяет к скольким ячейкам памяти можно обратиться одновременно. Проще говоря, сколько входов адреса существует. Все примеры памяти рассмотренные выше являются однопортовыми, то есть у них один порт. Например, если у памяти 2 входа адреса addr1 и addr2 — это двухпортовая память. При этом не важно, можно ли по этим адресам только читать/писать или выполнять обе операции.

Регистровый файл, который будет реализован в рамках данной работы, является трехпортовым, и имеет 2 порта на чтение и 1 порт на запись.

С точки зрения аппаратной реализации память в ПЛИС может быть блочной, распределенной или регистровой. Блочная память — это аппаратный блок памяти, который можно сконфигурировать под свои нужды. Распределенная и регистровая память (в отличие от блочной) реализуется на конфигурируемых логических блоках (см. как работает ПЛИС). Такая память привязана к расположению конфигурируемых логических блоков ПЛИС и как бы равномерно распределена по всему кристаллу. Вместо реализации логики конфигурируемые логические блоки используются для нужд памяти. Чтобы понять почему это возможно, рассмотрим структуру логического блока:

../../.pic/Labs/lab_03_memory/fig_02.png

Рисунок 2. Структурная схема логического блока в ПЛИС[1].

В логическом блоке есть таблицы подстановки (Look Up Table, LUT), которые представляют собой не что иное как память, которая переконфигурируется под нужды хранения, а не реализацию логики. Таким образом, трехвходовой LUT может выступать в роли восьмиразрядной памяти.

Однако LUT будет сложно приспособить под многопортовую память: посмотрим на схему еще раз: три входа LUT формируют адрес одной из восьми ячеек. Это означает, что среди этих восьми ячеек нельзя обратиться к двум из них одновременно.

Для реализации многопортовой памяти небольшого размера лучше воспользоваться расположенным в логическом блоке D-триггером (DFF на рис. 2). Несмотря на то, что D-триггер позволяет воспроизвести только 1 разряд элемента памяти, он не ограничивает реализацию по портам.

Таким образом, плюс распределенной памяти относительно регистровой заключается в лучшей утилизации ресурсов: одним трёхвходовым LUT можно описать до 8 бит распределенной памяти, в то время как одним D-триггером можно описать только один бит регистровой памяти. Предположим, что в ПЛИС размещены логические блоки, структура которых изображена на рис. 2 и нам необходимо реализовать 1KiB памяти. Мы можем реализовать распределенную память, используя 64 логических блока (в каждом блоке два трёхвходовых LUT), либо регистровую память, используя 1024 логических блока.

Минусом является ограниченность в реализации многопортовой памяти.

Сравним блочную память с распределенной/регистровой: поскольку большой объем памяти съест много логических блоков при реализации распределенной/регистровой памяти, такую память лучше делать в виде блочной.

В то же время, к плюсам распределенной/регистровой памяти можно отнести возможность синтезировать память с асинхронным портом на чтение, чем мы и воспользуемся при реализации однотактного процессора (если бы порт чтения памяти был синхронным, нам потребовалось ждать один такт, чтобы получить инструкцию из памяти инструкций или данные из регистрового файла, что затруднило бы реализацию однотактного процессора, где каждая инструкция должна выполняться ровно за один такт).

Обычно синтезатор сам понимает, какой вид памяти подходит под описанную схему на языке SystemVerilog.

В случае, если под описанную схему подходит несколько видов памяти, есть возможность выбрать конкретную вручную, причем способы могут различаться от производителя к производителю, поэтому за подробностями лучше обращаться к документации. Например у Xilinx за это отвечает следующий раздел документации по синтезу.

Инструменты для реализации памяти

Описание памяти на языке SystemVerilog

Память на языке SystemVerilog объявляется подобно регистрам, используя ключевое слово logic. Но, кроме разрядности (разрядности ячеек памяти, в данном случае) после имени регистра (памяти, в данном случае) указывается количество создаваемых ячеек либо в виде натурального числа, либо в виде диапазона адресов этих ячеек.:

logic [19:0] memory1 [16];    // memory1 и memory2 являются полностью
logic [19:0] memory2 [0:15];  // идентичными памятями.

logic [19:0] memory3 [15:0];  // memory3 будет такой же памятью, что и
                              // предыдущие, но на временной диаграмме
                              // Vivado при ее отображении сперва будут
                              // идти ячейки, начинающиеся со старших
                              // адресов (что в рамках данного курса
                              // лабораторных работ будет скорее минусом).


logic [19:0] memory3 [1:16];  // А вот memory3 хоть и совпадает по
                              // размеру с предыдущими реализациями,
                              // но отличается по адресному пространству
                              // обращение по нулевому адресу выдаст
                              // недетерминированный результат. Это не
                              // значит, что память будет плохой или
                              // дефектной, просто надо учитывать эту её
                              // особенность.

В приведенном листинге logic [19:0] memory1 [16]; создается память с шестнадцатью (от 0-го до 15-го адреса) 20-битными ячейками памяти. В таком случае говорят, что ширина памяти 20 бит, а глубина 16. Для адресации такой памяти потребуется адрес с разрядностью ceil(log2(16)) = 4 бита (ceil — операция округления вверх). Это однопортовая память.

Для обращения к конкретной ячейке памяти используются квадратные скобки с указанием нужного адреса memory[addr]. Грубо говоря, то, что указывается в квадратных скобках будет подключено ко входу адреса памяти memory.

Реализация асинхронного подключения к выходу памяти осуществляется оператором assign. А, если требуется создать память с синхронным чтением, то присваивание выходу требуется описать внутри блокаalways_ff.

Так как запись в память является синхронным событием, то описывается она в конструкции always_ff. При этом, как и при описании регистра, можно реализовать управляющий сигнал разрешения на запись через блок вида if(write_enable).

module mem16_20 (                     // создать блок с именем mem16_20
  input  logic         clk,           // вход синхронизации
  input  logic [3:0]   addr,          // адресный вход
  input  logic [19:0]  write_data,    // вход данных для записи
  input  logic         write_enable,  // сигнал разрешения на запись
  output logic [19:0]  async_read_data// асинхронный выход считанных данных
  output logic [19:0]  sync_read_data // синхронный выход считанных данных
);

  logic [19:0] memory [0:15];         // создать память с 16-ю
                                      // 20-битными ячейками

  // асинхронное чтение
  assign async_read_data = memory[addr];  // подключить к выходу async_read_data
                                          // ячейку памяти по адресу addr
                                          // (асинхронное чтение)

  // синхронное чтение
  always_ff @(posedge clk) begin     // поставить перед выходом sync_read_data
    sync_read_data <= memory[addr];  // регистр, в который каждый такт будут
  end                                // записываться считываемые данные

  // запись
  always_ff @(posedge clk) begin    // каждый раз по фронту clk
    if(write_enable) begin          // если сигнал write_enable == 1, то
      memory[addr] <= write_data;   // в ячейку по адресу addr будут записаны
                                    // данные сигнала write_data
    end
  end
endmodule

В случае реализации ПЗУ нет необходимости в описании входов для записи, поэтому описание памяти занимает всего пару строк. Чтобы проинициализировать такую память (то есть поместить в нее начальные значения, которые можно было бы из нее читать), требуемое содержимое нужно добавить к прошивке, вместе с которой данные попадут в ПЛИС. Для этого в проект добавляется текстовый файл формата .mem с содержимым памяти. Для того, чтобы отметить данный файл в качестве инициализирующего память, можно использовать системную функцию $readmemh.

У данной функции есть два обязательных аргумента:

  • имя инициализирующего файла
  • имя инициализируемой памяти

и два опциональных:

  • стартовый адрес, начиная с которого память будет проинициализирована данным файлом (по-умолчанию равен нулю)
  • конечный адрес, на котором инициализация закончится (даже если в файле были ещё какие-то данные).

Пример полного вызова выглядит так:

$readmemh("<data file name>",<memory name>,<start address>,<end address>);

Однако на деле обычно используются только обязательные аргументы:

$readmemh("<data file name>",<memory name>);

Пример описанной выше памяти:

module rom16_8 (
  input  logic [3:0]   addr1,       // первый 4-битный адресный вход
  input  logic [3:0]   addr2,       // второй 4-битный адресный вход
  output logic [7:0]   read_data1,  // первый 8-битный выход считанных данных
  output logic [7:0]   read_data2   // второй 8-битный выход считанных данных
);

  logic [7:0] ROM [0:15];           // создать память с 16-ю 8-битными ячейками

  initial begin
    $readmemh("rom_data.mem", ROM); // поместить в память RAM содержимое
  end                               // файла rom_data.mem


  assign read_data1 = R0M[addr1];   // реализация первого порта на чтение
  assign read_data2 = R0M[addr2]    // реализация второго порта на чтение

endmodule

Содержимое файла rom_data.mem, к примеру может быть таким (каждая строка соответствует значению отдельной ячейки памяти, начиная со стартового адреса):

  FA
  E6
  0D
  15
  A7

Для того, чтобы при сборке модуля не было проблем с путями, по которым будет искаться данный файл, обычно его необходимо добавить в проект. В случае Vivado, чтобы тот распознал этот файл как инициализирующий память, необходимо чтобы у этого файла было расширение .mem.

Задание по реализации памяти

Необходимо описать на языке SystemVerilog три вида памяти:

  1. память инструкций;
  2. память данных;
  3. регистровый файл.

1. Память инструкций

У данного модуля будет два входных/выходных сигнала:

  • 32-битный вход адреса
  • 32-битный выход данных (асинхронное чтение)
mоdulе instr_mеm(
  inрut  logic [31:0] addr_i,
  оutрut logic [31:0] rеаd_dаtа_o
);

Не смотря на разрядность адреса, на практике, внутри данного модуля вы должны будете реализовать память с 1024-мя 32-битными ячейками (в ПЛИС попросту не хватит ресурсов на реализации памяти с 232 ячеек). Таким образом, реально будет использоваться только 10 бит адреса.

При этом по спецификации процессор RISC-V использует память с побайтовой адресацией. Байтовая адресация означает, что процессор способен обращаться к отдельным байтам в памяти (за каждым байтом памяти закреплен свой индивидуальный адрес).

Однако, если у памяти будут 32-рязрядные ячейки, доступ к конкретному байту будет осложнен, ведь каждая ячейка — это 4 байта. Как получить данные третьего байта памяти? Если обратиться к третьей ячейке в массиве — придут данные 12-15-ых байт (поскольку каждая ячейка содержит по 4 байта). Чтобы получить данные третьего байта, необходимо разделить значение пришедшего адреса на 4 (отбросив остаток от деления). 3 / 4 = 0 — и действительно, если обратиться к нулевой ячейке памяти — будут получены данные 3-го, 2-го, 1-го и 0-го байт. То, что помимо значения третьего байта есть еще данные других байт нас в данный момент не интересует, важна только сама возможность указать адрес конкретного байта.

Деление на 2n можно осуществить, отбросив n младших бит числа. Учитывая то, что для адресации 1024 ячеек памяти мы будем использовать 10 бит адреса, память инструкций должна выдавать на выход данные, расположенные по адресу addr_i[11:2].

2. Память данных

У данного модуля будет шесть входных/выходных сигналов:

  • вход тактового синхроимпульса
  • вход запроса на работу с памятью
  • вход сигнала разрешения записи
  • 32-битный вход адреса
  • 32-битный вход данных записи
  • 32-битный выход данных синхронного чтения
mоdulе data_mеm(
  inрut  logic        clk_i,
  input  logic        mem_req_i,
  inрut  logic        write_enable_i,
  inрut  logic [31:0] addr_i,
  inрut  logic [31:0] write_data_i,
  оutрut logic [31:0] rеаd_dаtа_o
);

Как и память инструкций, память данных будет состоять из 32-разрядных ячеек. Только теперь их будет 4096, а значит при обращении к ячейкам памяти нужно использовать не 10 бит адреса, а 12. При этом по-прежнему необходимо разделить пришедший адрес на 4, т.е. нужно отбросить два младших бита. Таким образом, обращение к ячейкам памяти (для записи и чтения) должно осуществляться по адресу addr_i[13:2].

Однако в отличие от памяти инструкций, в память данных добавлено два управляющих сигнала (mem_req_iи write_enable_i). Сигнал mem_req_i является сигналом запроса на работу с памятью. Без этого сигнала память не должна выполнять операции чтения/записи (вне зависимости от сигнала write_enable, определяющего происходит сейчас запись или чтение). Как сделать так, чтобы не происходило чтение без запроса? Например, не обновлять значение, считанное во время предыдущей операции чтения.

Если mem_req_i == 1 и write_enable_i == 1, то происходит запрос на запись в память. В этом случае, необходимо записать значение write_data_i в ячейку по адресу addr_i[13:2]. Во всех других случаях (любой из сигналов mem_req_i, write_enable_i равен нулю), запись в память не производится.

Если mem_req_i == 1 и write_enable_i == 0, то происходит запрос на чтение из памяти. В этом случае, необходимо записать в выходной регистр read_data_o значение из ячейки по адресу addr_i[13:2]. Во всех других случаях чтение из памяти не производится (read_data_o сохраняет предыдущее значение).

3. Регистровый файл

У данного модуля будет восемь входных/выходных сигналов:

  • вход тактового синхроимпульса
  • вход сигнала разрешения записи
  • 5-битный вход первого адреса чтения
  • 5-битный вход второго адреса чтения
  • 5-битный вход адреса записи
  • 32-битный вход данных записи
  • 32-битный выход данных асинхронного чтения по первому адресу
  • 32-битный выход данных асинхронного чтения по второму адресу
mоdulе rf_r𝚒sсv(
  inрut  logic        сlk_i,
  inрut  logic        write_enable_i,

  inрut  logic [ 4:0] write_addr_i,
  inрut  logic [ 4:0] read_addr1_i,
  inрut  logic [ 4:0] read_addr2_i,

  inрut  logic [31:0] write_data_i,
  оutрut logic [31:0] read_data1_o,
  оutрut logic [31:0] read_data2_o
);

На языке SystemVerilog необходимо реализовать модуль регистрового файла (rf_r𝚒sсv) для процессора с архитектурой RISC-V, представляющего собой трехпортовую ОЗУ с двумя портами на чтение и одним портом на запись и состоящей из 32-х 32-битных регистров с именем rf_mem.

При этом по адресу 0 должно всегда считываться значение 0 вне зависимости от того, какое значение в этой ячейке памяти, и есть ли она вообще. Такая особенность обусловлена тем, что при выполнении операций очень часто используется ноль (сравнение с нулем, инициализация переменных нулевым значением, копирование значения одного регистра в другой посредством сложения с нулем и записи результата и т.п.).

Как и в случае реализации памяти инструкций, вы можете решить эту задачу с помощью мультиплексора, управляющим сигналом которого является сигнал сравнения адреса на чтение с нулем.

Либо же можно проинициализировать нулевую ячейку памяти нулем с запретом записи в неё каких-либо значений. В этом случае в ячейке всегда будет ноль, а значит и считываться с нулевого адреса будет только он.

Порядок выполнения работы

  1. Внимательно ознакомьтесь с заданием. В случае возникновения вопросов, проконсультируйтесь с преподавателем.
  2. Реализуйте память инструкций. Для этого:
    1. В Design Sources проекта с предыдущих лаб, создайте SystemVerilog-файл instr_mem.sv.
    2. Опишите в нем модуль памяти инструкций с таким же именем и портами, как указано в задании.
      1. Сперва необходимо создать память (массив регистров). Как это сделать, сказано в разделе описание памяти на языке SystemVerilog. Разрядность ячеек памяти должна быть 32 бита, количество ячеек — 1024.
      2. Добавить в Design Sources проекта файл с содержимым памяти инструкций. Данный файл будет использоваться при вызове системной функции $readmemh в описании памяти инструкций.
      3. К созданной памяти необходимо подключить выход модуля read_data_o. При подключении должен быть использован вход модуля addr_i, значение которого должно быть уменьшено в 4 раза (побайтовая адресация).
      4. При реализации выхода read_data_o помните, что обращаясь к ячейке памяти, вам необходимо использовать [11:2] биты адреса.
      5. Реализуемый порт на чтение памяти инструкций должен быть асинхронным.
    3. После описания памяти инструкций, её необходимо проверить с помощью тестового окружения.
      1. Тестовое окружение находится здесь.
      2. Для запуска симуляции воспользуйтесь этой инструкцией.
      3. Перед запуском симуляции убедитесь, что в качестве top-level модуля выбран корректный (tb_instr_mem).
      4. Во время симуляции, вы должны прожать "Run All" и убедиться, что в логе есть сообщение о завершении теста!
  3. Реализуйте память данных. Для этого:
    1. В Design Sources проекта создайте SystemVerilog-файл data_mem.sv.
    2. Опишите в нем модуль памяти данных с таким же именем и портами, как указано в задании.
      1. Описание модуля будет схожим с описанием модуля памяти инструкций, однако порт чтения в этот раз будет синхронным (запись в него будет происходить в блоке always_ff). Количество ячеек в памяти данных — 4096. Кроме того, необходимо будет описать логику записи данных в память.
      2. Запись в ячейки памяти описывается подобно записи данных в регистры, только при этом, происходит доступ к конкретной ячейке памяти с помощью входа addr_i (как осуществляется доступ к ячейкам памяти сказано в разделе описание памяти на языке SystemVerilog).
      3. Доступ к ячейкам (на запись и чтение) осуществляется по адресу addr_i[13:2].
      4. Обратите внимание что работа с памятью должна осуществляться только когда сигнал mem_req_i == 1, в противном случае запись не должна производиться, а на шине read_data_o должен оставаться результат предыдущего чтения.
    3. После описания памяти данных, её необходимо проверить с помощью тестового окружения.
      1. Тестовое окружение находится здесь.
      2. Для запуска симуляции воспользуйтесь этой инструкцией.
      3. Перед запуском симуляции убедитесь, что в качестве top-level модуля выбран корректный (tb_data_mem).
      4. Во время симуляции, вы должны прожать "Run All" и убедиться, что в логе есть сообщение о завершении теста!
  4. Реализуйте регистровый файл. Для этого:
    1. В Design Sources проекта создайте SystemVerilog-файл rf_riscv.sv.
    2. Опишите в нем модуль регистрового файла с таким же именем и портами, как указано в задании.
      1. Обратите внимание, что имя памяти (не название модуля, а имя объекта памяти внутри модуля) должно быть rf_mem. Такое имя необходимо для корректной работы верификационного окружения.
      2. Как и у памяти инструкций, порты чтения регистрового файла должны быть асинхронными.
      3. Не забывайте, что у вас 2 порта на чтение и 1 порт на запись, при этом каждый порт не зависит от остальных (в модуле 3 независимых входа адреса).
      4. Чтение из нулевого регистра (чтение по адресу 0) всегда должно возвращать нулевое значение. Этого можно добиться двумя путями:
        1. Путем добавления мультиплексора перед выходным сигналом чтения (мультиплексор будет определять, пойдут ли на выход данные из ячейки регистрового файла, либо, в случае если адрес равен нулю, на выход пойдет константа ноль).
        2. Путем инициализации нулевого регистра нулевым значением и запретом записи в этот регистр (при записи и проверки write_enable добавить дополнительную проверку на адрес).
        3. Каким образом будет реализована эта особенность регистрового файла не важно, выберите сами.
    3. После описания регистрового файла, его необходимо проверить с помощью тестового окружения.
      1. Тестовое окружение находится здесь.
      2. Для запуска симуляции воспользуйтесь этой инструкцией.
      3. Перед запуском симуляции убедитесь, что в качестве top-level модуля выбран корректный (tb_rf_riscv).
      4. Во время симуляции, вы должны прожать "Run All" и убедиться, что в логе есть сообщение о завершении теста!
  5. Проверьте работоспособность вашей цифровой схемы в ПЛИС. Для этого:
    1. Добавьте файлы из папки board files в проект.
      1. Файл nexys_rf_riscv.sv необходимо добавить в Design Sources проекта.
      2. Файл nexys_a7_100t.xdc необходимо добавить в Constraints проекта. В случае, если вы уже добавляли одноименный файл в рамках предыдущих лабораторных работ, его содержимое необходимо заменить содержимым нового файла.
    2. Выберите nexys_rf_riscv в качестве модуля верхнего уровня (top-level).
    3. Выполните генерацию битстрима и сконфигурируйте ПЛИС. Для этого воспользуйтесь следующей инструкцией.
    4. Описание логики работы модуля верхнего уровня и связи периферии ПЛИС с реализованным модулем находится в папке board files.

Источники

  1. Field-programmable gate array